Skip to content

feat: add GraphQL subscriptions over SSE (G3) - #1592

Merged
jlouvel merged 2 commits into
feat/graphql-g2-clientfrom
feat/graphql-g3-sse
Nov 1, 2026
Merged

jlouvel merged 2 commits into
feat/graphql-g2-clientfrom
feat/graphql-g3-sse

Conversation

@jlouvel

@jlouvel jlouvel commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1585. Part of #1581.

Stacked on #1591 (G2) → #1590 (G1) → #1589 (G0). Merge those first.

Summary

Subscriptions with graphql-sse in distinct connections mode, on core SSE (EventStreamRepresentation, EventStreamReader). WebSocket comes in G4.

// Server
graphql.getSubscriptionTransports().add(GraphqlTransport.SSE);

// Client
Publisher<ExecutionResult> ticks = client.subscribe("subscription { ticks }", null, null);

Server

  • A request accepting text/event-stream (named explicitly; wildcards don't select it) gets an event stream: next events carrying results, then complete. Queries and mutations are one next; subscriptions are also accepted over GET when GET is enabled. Request errors are 400 with a JSON body, as in graphql-sse.
  • Back-pressure: one result requested at a time, the next one after the event is written, so the connector's flow control reaches the publisher.
  • Disconnects: a failed write cancels the subscription. Heartbeats (15 s by default, configurable or off) keep idle streams open through proxies and detect disconnected idle clients.
  • An empty comment is sent first so that the response head goes out at once. Without it, an idle subscription with heartbeats off kept the client waiting for headers (about 30 s in tests).
  • Limits: 64 active subscriptions per adapter (503 beyond), 1 MiB of queued bytes per subscription. The queue only grows when a publisher ignores demand or a result is huge; beyond the limit the stream ends with an error and the subscription is cancelled. The connector's server.maxEventStreams still applies.
  • stop() ends the adapter's open streams (error + complete), so that the server connector shuts down gracefully instead of waiting for idle subscriptions.
  • Publisher failures are logged; subscribers receive a generic message.

Client

  • subscribe(String|Document, operationName, variables) returns a cold Publisher<ExecutionResult>. Each subscriber sends its own POST on its first demand, from a virtual thread that emits all signals (no concurrent onNext).
  • Demand is honored; cancel() aborts the request (Request.abort()); request(n <= 0) signals IllegalArgumentException.
  • Request errors (server 400, or local validation with setSchema) are published as a single result. Non-GraphQL responses, events above maxResponseBytes and streams ending without complete are a GraphqlTransportException.
  • stop() fails open subscriptions with a GraphqlTransportException; the borrowed client keeps running.

Core findings

  • EventStreamReader has no line or event size limit (BufferedReader.readLine). The client wraps the stream with a per-event byte budget (one 8 KiB read-ahead margin); a bounded reader in core would be better.
  • EventStreamRepresentation does not commit the response head until the first event or heartbeat.

Validation

./mvnw -B verify -P default,test -pl org.restlet.ext.graphql: 47 tests, 0 failures; 93.8% instruction coverage (gate 80%); Spotless, SpotBugs, Javadoc, -Werror and dependency convergence pass.

New GraphqlSseTestCase (16 tests): wire format of next/complete; queries over SSE; JSON request errors; GET subscriptions and GET mutations rejected; SSE off unless enabled; client disconnect cancels a busy subscription; heartbeats detect a disconnected idle client; subscription cap; queued bytes limit; client demand (no result without demand), completion, cancellation aborting the server-side subscription, stop(), request errors as results (remote and local validation), transport errors (HTML 502, truncated stream, oversized event), invalid demand; configuration validation.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Early client disconnects can bypass cleanup and permanently exhaust subscription capacity, with additional unresolved subscription-lifecycle and SSE protocol defects.

8 open issues
What changed in this PR

Adds GraphQL subscriptions over Server-Sent Events (SSE), extending the existing HTTP binding with graphql-sse distinct-connections support built on Restlet’s core SSE APIs.

Changes:

  • Adds server streaming with heartbeats, subscription limits, back-pressure, and shutdown handling.
  • Adds cold client publishers with demand handling, cancellation, and transport-error reporting.
  • Adds SSE integration tests, idle-publisher fixtures, and a changelog entry.
File Description
org.restlet.ext.graphql/​src/​test/​java/​org/​restlet/​ext/​graphql/​TickPublisher.java Adds idle-publisher behavior for tests.
org.restlet.ext.graphql/​src/​test/​java/​org/​restlet/​ext/​graphql/​GraphqlTestSchemas.java Adds an idle subscription field.
org.restlet.ext.graphql/​src/​test/​java/​org/​restlet/​ext/​graphql/​GraphqlSseTestCase.java Tests SSE protocol, limits, demand, and lifecycle behavior.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​internal/​GraphqlEventStream.java Writes subscription results as bounded SSE streams.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlTransport.java Defines the SSE subscription transport.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlSsePublisher.java Implements client-side SSE subscriptions.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlServerAdapter.java Integrates SSE negotiation, configuration, and stream lifecycle.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlClientAdapter.java Exposes subscription APIs and tracks open subscriptions.
changes.md Documents GraphQL SSE support and default limits.

Review effort: Balanced

Comment on lines +398 to +401
private static Publisher<ExecutionResult> single(ExecutionResult result) {
return subscriber ->
subscriber.onSubscribe(
new Subscription() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: these publishers only replay a validation result computed locally before stop(), with no request or connection behind them, so there is nothing to cancel and the result stays correct. Tracking them would only turn a valid local answer into a stop error.

Comment on lines +455 to +457
if (subscription && activeSubscriptions.get() >= maxSubscriptions) {
writeEntity(response, responseType, failure(error("Too many active subscriptions")));
response.setStatus(Status.SERVER_ERROR_SERVICE_UNAVAILABLE);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining for now: the limit stays enforced atomically in GraphqlEventStream.write(), and the check in handle() only gives the common case a 503. A real reservation would have to be released when the connector never writes the entity (for example when it drops event streams over its own limit), and Restlet has no reliable hook for that, so a leaked reservation would be worse than the rare SSE error that a lost race gets.

@jlouvel
jlouvel force-pushed the feat/graphql-g3-sse branch from 6518f5f to e86b9df Compare October 31, 2026 06:13
@jlouvel
jlouvel force-pushed the feat/graphql-g3-sse branch from e86b9df to 7ddc19d Compare November 1, 2026 14:23
jlouvel and others added 2 commits November 1, 2026 15:09
graphql-sse in distinct connections mode, on core SSE:

- Server: GraphqlTransport.SSE enables event streams for requests
  accepting text/event-stream: subscriptions (also over GET when it is
  enabled), and queries and mutations as one next event. Results are
  requested one at a time and written as next events, then complete.
  Request errors are 400 with a JSON body. An initial comment sends the
  response head at once; heartbeats (15 s by default) keep idle streams
  open and detect disconnected clients. A failed write cancels the
  subscription.
- Limits: 64 active subscriptions per adapter (503 beyond), 1 MiB of
  queued bytes per subscription (the stream ends with an error and the
  subscription is cancelled). Stopping the adapter ends its open
  streams so that the server connector can shut down gracefully.
- Client: subscribe() returns a cold Publisher<ExecutionResult>; each
  subscriber sends its own request on its first demand, from a virtual
  thread that emits all the signals. Demand is honored, cancel() aborts
  the request, events are bounded by maxResponseBytes, request errors
  are published as a single result, and a stream ending without a
  complete event is a GraphqlTransportException. stop() fails the open
  subscriptions.

Refs #1585

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
On the server, release the subscription slot when the first write fails,
answer document errors of event stream requests with 400 and keep
adapters without subscription transports restartable. On the client,
signal non-positive demand without sending or waiting for a request,
unregister subscriptions cancelled from onSubscribe() and abort requests
cancelled before their exchange started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jlouvel
jlouvel force-pushed the feat/graphql-g3-sse branch from 7ddc19d to 08f497f Compare November 1, 2026 20:09
@jlouvel
jlouvel merged commit 56d86f8 into 3.0 Nov 1, 2026
1 check failed
@jlouvel
jlouvel deleted the feat/graphql-g3-sse branch November 1, 2026 20:10
@sonarqubecloud

sonarqubecloud Bot commented Nov 1, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants